How to organize the audio of a whole game, so it scales to hundreds of objects that make sounds.
A common first approach is to give every object its own Channel: one for the player, one per NPC, and more for every sound that does not belong to anyone. That quickly adds up to hundreds of channels, which are all processed all the time. This example uses three layers instead:
| Layer | What it is | How many | Cost |
|---|---|---|---|
| Bus (Channel) | A mixer channel with its own volume and effects | A handful, fixed | A few nodes each |
| Source (SpatialAudioSource) | A position and a gain, owned by a game object | Hundreds | Two GainNodes |
| Voice (internal) | The actual DSP chain: lowpass, panner, reverb send | Limited by maxVoices | Only for what you can hear |
output), and sources on other buses (bus).source.play()), fire-and-forget sounds (renderer.playAt()) and ambience loops.getStats(). +--> Effects bus ----+
SpatialAudioRenderer3D --+--> Entities bus ---+
(voices per bus, +--> Ambience bus ---+--> Master [Limiter] --> speakers
one shared reverb) +--> Voice chat bus -+
|
sound.play(...) ------------> UI bus ---------+
import { InputChannel, Limiter, Sound, SpatialAudioRenderer3D } from "@fluex/fluexgl-dsp";
// Assumes `audioDevice` from Example 01.
const master = audioDevice.getMasterChannel();
master.attachEffect(new Limiter({ ceiling: -1 }));
// --- Buses ---
const buses = {
effects: audioDevice.createChannel("Effects"),
entities: audioDevice.createChannel("Entities"),
ui: audioDevice.createChannel("UI"),
ambience: audioDevice.createChannel("Ambience"),
voiceChat: audioDevice.createChannel("Voice chat")
};
for (const bus of Object.values(buses))
bus.send(master);
// --- The world ---
// Everything the renderer plays goes to the Entities bus, unless a source has a bus of its own.
const world = new SpatialAudioRenderer3D(audioDevice, { output: buses.entities });
// --- Sounds ---
const sounds = {
punch: await Sound.load(audioDevice, "/sfx/punch.wav", { pitchVariation: 1 }),
gunshot: await Sound.load(audioDevice, "/sfx/gunshot.wav", { maxInstances: 8, pitchVariation: 0.5 }),
reload: await Sound.load(audioDevice, "/sfx/reload.wav", { maxInstances: 2 }),
growl: await Sound.load(audioDevice, "/sfx/growl.wav", { maxInstances: 6, steal: "quietest" }),
explosion: await Sound.load(audioDevice, "/sfx/explosion.wav", { maxInstances: 4, steal: "quietest" }),
impact: await Sound.load(audioDevice, "/sfx/impact.wav", { minInterval: 0.03, volumeVariation: 0.3 }),
torch: await Sound.load(audioDevice, "/sfx/torch-loop.wav", { loop: true, maxInstances: Infinity }),
click: await Sound.load(audioDevice, "/sfx/click.wav")
};
The player is the listener, so its own sounds are not positioned. They are played directly on the Effects bus.
function onPlayerShoot() {
sounds.gunshot.play(buses.effects);
}
function onPlayerReload() {
sounds.reload.play(buses.effects);
}
Every entity gets one source, not a channel. All of its sounds play through that source, so they move with it.
class Npc {
public x = 0;
public y = 0;
public z = 0;
private source = world.createSource({ label: "npc", bus: buses.entities });
public update() {
this.source.setPosition(this.x, this.y, this.z);
}
public attack() {
this.source.play(sounds.punch);
}
public growl() {
this.source.play(sounds.growl);
}
public destroy() {
world.removeSource(this.source); // fades out and stops its sounds
}
}
Explosions far away, objects that collide, bullet impacts: play them where they happen and forget about them.
function onExplosion(position) {
world.playAt(sounds.explosion, position, { bus: buses.effects, refDistance: 10, maxDistance: 400 });
}
function onCollision(a, b, contactPoint, strength) {
world.playAt(sounds.impact, contactPoint, { bus: buses.effects, volume: Math.min(1, strength) });
}
Collisions far away are culled (not played at all), and minInterval keeps a pile of colliding objects from playing the same sound 40 times in one frame.
A torch, a waterfall or a generator is a loop at a fixed position.
for (const torch of level.torches)
world.playAt(sounds.torch, torch.position, { bus: buses.ambience, loop: true, volume: 0.5 });
A level can have hundreds of them. Loops that nobody can hear (too far away, or out of the voice budget) are suspended after half a second: their audio nodes are released, and they resume in time when you come closer. See Loop virtualization.
Keep the returned SoundInstance if the loop must stop later, for example when the torch is put out:
torch.sound = world.playAt(sounds.torch, torch.position, { bus: buses.ambience, loop: true });
// later:
torch.sound?.stop(0.5);
Every remote player gets an InputChannel with their stream, positioned by a source on the Voice chat bus. See Example 11 for the full setup.
function onRemotePlayerJoined(player, stream: MediaStream) {
const voice = new InputChannel(audioDevice.getContext(), player.name);
voice.setMediaStream(stream);
player.voiceSource = world.createSource({ bus: buses.voiceChat, clusterable: false });
player.voiceSource.attachChannel(voice);
}
button.addEventListener("click", () => sounds.click.play(buses.ui));
function frame() {
// See Example 05 for following a three.js camera, including its orientation.
world.listener.setPosition(camera.position.x, camera.position.y, camera.position.z);
for (const npc of npcs) npc.update();
world.update();
requestAnimationFrame(frame);
}
// Volume sliders: one per bus.
settings.onChange("effects", (v) => buses.effects.volume(v));
settings.onChange("voiceChat", (v) => buses.voiceChat.volume(v));
const stats = world.getStats();
debugText.textContent = `sources ${stats.sources}, voices ${stats.voices}/${world.options.maxVoices}, ` +
`virtual ${stats.virtual}, suspended loops ${stats.suspendedLoops}`;
output (here the Entities bus).output, the renderer does not add a limiter of its own. Put one on the master channel, as above, or pass limiter: true.world.master is null when the output is a Channel. Use the buses for volumes instead.